qia-client/src/components/ai/FloatingAIChat.tsx
ddshi 864051a65b feat: 优化列表滚动和输入框交互体验
- 纪念日/提醒列表添加动态底部填充,避免被 AI 输入框遮挡
- AI 输入框在弹窗和右键菜单打开时自动隐藏
- 优化输入框样式,提升在浅色背景上的可见度

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 20:20:04 +08:00

567 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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">
15520
</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>
</>
);
}