- 纪念日/提醒列表添加动态底部填充,避免被 AI 输入框遮挡 - AI 输入框在弹窗和右键菜单打开时自动隐藏 - 优化输入框样式,提升在浅色背景上的可见度 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
567 lines
20 KiB
TypeScript
567 lines
20 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
||
import {
|
||
Paper,
|
||
Text,
|
||
Stack,
|
||
Group,
|
||
TextInput,
|
||
ActionIcon,
|
||
Loader,
|
||
Box,
|
||
Transition,
|
||
Button,
|
||
Badge,
|
||
Divider,
|
||
} from '@mantine/core';
|
||
import {
|
||
IconSparkles,
|
||
IconSend,
|
||
IconCalendar,
|
||
IconRepeat,
|
||
IconFlag,
|
||
} from '@tabler/icons-react';
|
||
import { api } from '../../services/api';
|
||
import { useAppStore } from '../../stores';
|
||
import type { AIConversation, AIParsedEvent, RepeatType, PriorityType } from '../../types';
|
||
import { notifications } from '@mantine/notifications';
|
||
|
||
interface FloatingAIChatProps {
|
||
onEventCreated?: () => void;
|
||
hidden?: boolean;
|
||
}
|
||
|
||
export function FloatingAIChat({ onEventCreated, hidden = false }: FloatingAIChatProps) {
|
||
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' });
|
||
};
|
||
|
||
useEffect(() => {
|
||
scrollToBottom();
|
||
}, [history, showPreview, loading]);
|
||
|
||
// 聚焦时展开
|
||
const handleFocus = () => {
|
||
setIsFocused(true);
|
||
};
|
||
|
||
// 点击遮罩层关闭
|
||
const handleClose = () => {
|
||
setIsFocused(false);
|
||
setShowPreview(false);
|
||
setParsedEvent(null);
|
||
};
|
||
|
||
const handleSend = async () => {
|
||
if (!message.trim() || loading) return;
|
||
|
||
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: responseText,
|
||
created_at: new Date().toISOString(),
|
||
};
|
||
|
||
setHistory((prev) => [...prev, newConversation]);
|
||
addConversation(newConversation);
|
||
|
||
if (onEventCreated) {
|
||
onEventCreated();
|
||
}
|
||
} catch (error) {
|
||
console.error('AI parse error:', error);
|
||
notifications.show({
|
||
title: '解析失败',
|
||
message: 'AI 解析出错,请重试',
|
||
color: 'red',
|
||
});
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
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' ? '纪念日' : '提醒';
|
||
};
|
||
|
||
// 当 hidden 为 true 时,不渲染组件
|
||
if (hidden) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{/* 遮罩层 */}
|
||
<Transition mounted={isFocused} transition="fade" duration={200}>
|
||
{(styles) => (
|
||
<Box
|
||
style={{
|
||
...styles,
|
||
position: 'fixed',
|
||
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.75)',
|
||
backdropFilter: isFocused ? 'none' : 'blur(12px)',
|
||
borderRadius: 16,
|
||
boxShadow: isFocused
|
||
? '0 8px 32px rgba(0, 0, 0, 0.12)'
|
||
: '0 2px 12px rgba(0, 0, 0, 0.08)',
|
||
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||
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',
|
||
overflow: 'hidden',
|
||
boxShadow: '0 -4px 24px rgba(0, 0, 0, 0.1)',
|
||
}}
|
||
>
|
||
{/* Header */}
|
||
<Box
|
||
p="sm"
|
||
style={{
|
||
borderBottom: '1px solid var(--mantine-color-gray-1)',
|
||
background: 'var(--mantine-color-gray-0)',
|
||
}}
|
||
>
|
||
<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 area */}
|
||
<Box
|
||
p="sm"
|
||
style={{
|
||
flex: 1,
|
||
overflowY: 'auto',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '8px',
|
||
minHeight: 160,
|
||
maxHeight: 320,
|
||
}}
|
||
>
|
||
{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} style={{ marginBottom: 4 }}>
|
||
<Group gap={6} mb={4}>
|
||
<Text size="xs" c="dimmed" fw={500}>
|
||
你
|
||
</Text>
|
||
</Group>
|
||
<Paper
|
||
p="xs"
|
||
radius={8}
|
||
mb="xs"
|
||
style={{
|
||
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.5 }}>
|
||
{conv.message}
|
||
</Text>
|
||
</Paper>
|
||
|
||
<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
|
||
p="xs"
|
||
radius={8}
|
||
style={{
|
||
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.5 }}>
|
||
{conv.response}
|
||
</Text>
|
||
</Paper>
|
||
</Box>
|
||
))}
|
||
|
||
{loading && (
|
||
<Group gap={6}>
|
||
<Loader size="sm" color="blue" />
|
||
<Text size="xs" c="dimmed">
|
||
AI 正在思考...
|
||
</Text>
|
||
</Group>
|
||
)}
|
||
|
||
{showPreview && parsedEvent && (
|
||
<Box style={{ marginTop: 4 }}>
|
||
<Paper
|
||
p="sm"
|
||
radius={12}
|
||
style={{
|
||
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)}
|
||
onKeyDown={handleKeyDown}
|
||
onFocus={handleFocus}
|
||
placeholder="输入你想记住的事情..."
|
||
size="sm"
|
||
style={{ flex: 1 }}
|
||
disabled={loading}
|
||
styles={{
|
||
input: {
|
||
borderRadius: 12,
|
||
borderColor: 'var(--mantine-color-gray-3)',
|
||
background: isFocused ? 'rgba(255, 255, 255, 0.95)' : 'rgba(0, 0, 0, 0.06)',
|
||
color: '#1a1a1a',
|
||
paddingLeft: 12,
|
||
paddingRight: 12,
|
||
transition: 'all 0.3s ease',
|
||
},
|
||
}}
|
||
/>
|
||
<ActionIcon
|
||
size="lg"
|
||
radius={10}
|
||
variant="filled"
|
||
onClick={handleSend}
|
||
disabled={!message.trim() || loading}
|
||
style={{
|
||
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={16} color={(!message.trim() || loading) ? 'var(--mantine-color-gray-6)' : 'white'} />
|
||
</ActionIcon>
|
||
</Group>
|
||
</Box>
|
||
</Paper>
|
||
</Box>
|
||
</>
|
||
);
|
||
}
|