From 5f1c6208df1d320287887f46777313fb1a74a161 Mon Sep 17 00:00:00 2001 From: ddshi <8811906+ddshi@user.noreply.gitee.com> Date: Fri, 13 Feb 2026 11:53:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20AI=20=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E6=A1=86=E4=BA=A4=E4=BA=92=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构 AI 输入框为底部悬浮式,聚焦展开 - 添加预览卡片支持编辑重复、颜色等选项 - 优化时区显示和日期格式化 - 添加 loading 状态和 Toast 提示 - 支持确认后自动关闭并显示成功通知 Co-Authored-By: Claude Opus 4.5 --- src/components/ai/FloatingAIChat.tsx | 619 +++++++++++++----- .../anniversary/AnniversaryCard.tsx | 105 ++- src/pages/HomePage.tsx | 25 +- src/types/index.ts | 8 +- src/utils/countdown.ts | 87 ++- 5 files changed, 630 insertions(+), 214 deletions(-) diff --git a/src/components/ai/FloatingAIChat.tsx b/src/components/ai/FloatingAIChat.tsx index 16ec212..68bbdb7 100644 --- a/src/components/ai/FloatingAIChat.tsx +++ b/src/components/ai/FloatingAIChat.tsx @@ -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([]); + const [parsedEvent, setParsedEvent] = useState(null); + const [showPreview, setShowPreview] = useState(false); const addConversation = useAppStore((state) => state.addConversation); const scrollRef = useRef(null); + const inputRef = useRef(null); + + // 编辑状态 + const [editForm, setEditForm] = useState({ + 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 */} - setIsOpen(!isOpen)} - style={{ - position: 'fixed', - bottom: 24, - right: 24, - zIndex: 1000, - background: '#1a1a1a', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)', - }} - > - {isOpen ? : } - - - {/* Chat window */} - + {/* 遮罩层 */} + {(styles) => ( - + )} + + + {/* 悬浮容器 - 聚焦时放大 */} + + + {/* 展开的聊天面板 */} + + {(panelStyles) => ( + + {/* Header */} + + + + + + AI 助手 + + + + + + + + + {/* Chat area */} + + {history.length === 0 && !showPreview && !loading ? ( + + + + 告诉 AI 帮你添加纪念日或提醒 + + + 例如:「下周三见客户」「每月15号还房贷」「明年5月20日结婚纪念日」 + + + ) : ( + <> + {history.map((conv) => ( + + + + 你 + + + + + {conv.message} + + + + + + + AI + + + + + {conv.response} + + + + ))} + + {loading && ( + + + + AI 正在思考... + + + )} + + {showPreview && parsedEvent && ( + + + + + + {getTypeDisplay()} + + + {editForm.title || '未设置'} + + + + + + + + + 日期 + + {getDateDisplay()} + + + + + + 重复 + + + {['none', 'daily', 'weekly', 'monthly', 'yearly'].map((type) => ( + + ))} + + + + {editForm.type === 'reminder' && ( + + + + 颜色 + + + {[ + { value: 'none', color: 'var(--mantine-color-gray-3)' }, + { value: 'red', color: '#ef4444' }, + { value: 'green', color: '#22c55e' }, + { value: 'yellow', color: '#eab308' }, + ].map((item) => ( + 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', + }} + /> + ))} + + + )} + + + + + + + + + )} + + )} + +
+ + + )} + + + {/* 输入框区域 */} + - {/* Header */} - - - - - AI 助手 - - - - - {/* Chat history */} - - {history.length === 0 ? ( - - - - 告诉我你想记住的事情 -
- - 例如:下周三见客户、每月15号还房贷 - -
-
- ) : ( - history.map((conv) => ( - - - - 你 - - - - - {conv.message} - - - - - - - AI - - - - - {conv.response} - - - - )) - )} - - {loading && ( - - - - AI 正在思考... - - - )} - -
- - - {/* Input */} - + 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', }, }} /> - + - - )} - + + + ); } diff --git a/src/components/anniversary/AnniversaryCard.tsx b/src/components/anniversary/AnniversaryCard.tsx index d2b161a..0a76f3c 100644 --- a/src/components/anniversary/AnniversaryCard.tsx +++ b/src/components/anniversary/AnniversaryCard.tsx @@ -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 ( (e.currentTarget.style.transform = 'translateY(0)')} > + {/* 左侧:纪念日名称、循环icon、下一次日期 */} - - {event.title} - - - - {/* Countdown */} + - {formattedCountdown} + {event.title} - - {/* Date display */} + {/* 节假日标签 - 浅浅显示 */} + {(event.is_holiday || holiday) && ( + + 节假日 + + )} + + + {/* 循环icon */} + {repeatType !== 'none' && ( + + )} - {countdown.isPast - ? '已过' - : `${countdown.nextDate.getMonth() + 1}月${countdown.nextDate.getDate()}日`} - {isLunar && ' (农历)'} + {getNextDateText()} - - {repeatType !== 'none' && ( - - {repeatType === 'yearly' ? '每年' : '每月'} - - )} - {(event.is_holiday || holiday) && ( - - 节假日 - - )} - + {/* 右侧:倒数剩余天数 */} + + {countdown.isPast ? '已过' : `${countdown.days}天`} + ); diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 7f116ae..5aeb8fc 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -391,6 +391,8 @@ export function HomePage() { onDateChange={handleDateChange} onPriorityChange={handlePriorityChange} /> + {/* 底部空白区域 - 避免被 AI 输入框遮挡 */} +
{/* 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() {
- {/* Lunar switch (only for anniversaries) */} + {/* Lunar switch (only for anniversaries, disabled in edit mode) */} {formType === 'anniversary' && ( + 农历日期 } checked={formIsLunar} onChange={(e) => setFormIsLunar(e.currentTarget.checked)} + disabled={isEdit} + styles={{ + track: { + opacity: isEdit ? 0.5 : 1, + }, + }} /> )} diff --git a/src/types/index.ts b/src/types/index.ts index 3bcd232..0268d2b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 { diff --git a/src/utils/countdown.ts b/src/utils/countdown.ts index 3c59361..46bf6c9 100644 --- a/src/utils/countdown.ts +++ b/src/utils/countdown.ts @@ -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) { - isPast = true; + 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);