Compare commits
2 Commits
5f1c6208df
...
8725108195
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8725108195 | ||
|
|
864051a65b |
@ -27,9 +27,10 @@ import { notifications } from '@mantine/notifications';
|
|||||||
|
|
||||||
interface FloatingAIChatProps {
|
interface FloatingAIChatProps {
|
||||||
onEventCreated?: () => void;
|
onEventCreated?: () => void;
|
||||||
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
|
export function FloatingAIChat({ onEventCreated, hidden = false }: FloatingAIChatProps) {
|
||||||
const [isFocused, setIsFocused] = 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);
|
||||||
@ -212,6 +213,11 @@ export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
|
|||||||
return editForm.type === 'anniversary' ? '纪念日' : '提醒';
|
return editForm.type === 'anniversary' ? '纪念日' : '提醒';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 当 hidden 为 true 时,不渲染组件
|
||||||
|
if (hidden) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 遮罩层 */}
|
{/* 遮罩层 */}
|
||||||
@ -246,13 +252,13 @@ export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
|
|||||||
<Paper
|
<Paper
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
background: isFocused ? 'var(--mantine-color-body)' : 'rgba(255, 255, 255, 0.4)',
|
background: isFocused ? 'var(--mantine-color-body)' : 'rgba(255, 255, 255, 0.75)',
|
||||||
backdropFilter: isFocused ? 'none' : 'blur(8px)',
|
backdropFilter: isFocused ? 'none' : 'blur(12px)',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
boxShadow: isFocused
|
boxShadow: isFocused
|
||||||
? '0 8px 32px rgba(0, 0, 0, 0.12)'
|
? '0 8px 32px rgba(0, 0, 0, 0.12)'
|
||||||
: '0 2px 12px rgba(0, 0, 0, 0.06)',
|
: '0 2px 12px rgba(0, 0, 0, 0.08)',
|
||||||
border: '1px solid var(--mantine-color-gray-2)',
|
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||||
transition: 'all 0.3s ease',
|
transition: 'all 0.3s ease',
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
}}
|
}}
|
||||||
@ -523,8 +529,9 @@ export function FloatingAIChat({ onEventCreated }: FloatingAIChatProps) {
|
|||||||
styles={{
|
styles={{
|
||||||
input: {
|
input: {
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
borderColor: 'var(--mantine-color-gray-2)',
|
borderColor: 'var(--mantine-color-gray-3)',
|
||||||
background: isFocused ? 'rgba(255, 255, 255, 0.95)' : 'rgba(255, 255, 255, 0.6)',
|
background: isFocused ? 'rgba(255, 255, 255, 0.95)' : 'rgba(0, 0, 0, 0.06)',
|
||||||
|
color: '#1a1a1a',
|
||||||
paddingLeft: 12,
|
paddingLeft: 12,
|
||||||
paddingRight: 12,
|
paddingRight: 12,
|
||||||
transition: 'all 0.3s ease',
|
transition: 'all 0.3s ease',
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||||
import { Stack, Text, Paper, Group, Button, Box } from '@mantine/core';
|
import { Stack, Text, Paper, Group, Button, Box } from '@mantine/core';
|
||||||
import { IconPlus } from '@tabler/icons-react';
|
import { IconPlus } from '@tabler/icons-react';
|
||||||
import { AnniversaryCard } from './AnniversaryCard';
|
import { AnniversaryCard } from './AnniversaryCard';
|
||||||
@ -29,6 +29,39 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
|
|||||||
const anniversaries = events.filter((e) => e.type === 'anniversary');
|
const anniversaries = events.filter((e) => e.type === 'anniversary');
|
||||||
const showHolidays = useAppStore((state) => state.settings?.showHolidays ?? true);
|
const showHolidays = useAppStore((state) => state.settings?.showHolidays ?? true);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [bottomPadding, setBottomPadding] = useState(0);
|
||||||
|
|
||||||
|
// 检测列表内容,添加底部填充避免被 AI 输入框遮挡
|
||||||
|
useEffect(() => {
|
||||||
|
const updatePadding = () => {
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// 判断是否可滚动(内容高度 > 容器高度)
|
||||||
|
const isScrollable = container.scrollHeight > container.clientHeight;
|
||||||
|
// 计算内容底部到容器底部的距离
|
||||||
|
const scrollBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
// 如果可滚动,始终添加填充;如果不可滚动但内容接近底部,也添加填充
|
||||||
|
const needsPadding = isScrollable || scrollBottom < 100;
|
||||||
|
// 底部填充高度:AI 输入框高度(约50px) + 底部间距(48px) + 额外缓冲(20px)
|
||||||
|
setBottomPadding(needsPadding ? 120 : 20);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 延迟执行确保 DOM 完全渲染
|
||||||
|
const timer = setTimeout(updatePadding, 100);
|
||||||
|
|
||||||
|
// 监听容器和内容变化
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
if (container) {
|
||||||
|
const observer = new ResizeObserver(updatePadding);
|
||||||
|
observer.observe(container);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
// 滚动条样式 - 仅在悬停时显示
|
// 滚动条样式 - 仅在悬停时显示
|
||||||
const scrollbarStyle = `
|
const scrollbarStyle = `
|
||||||
@ -179,7 +212,7 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
|
|||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
className="anniversary-scroll"
|
className="anniversary-scroll"
|
||||||
style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}
|
style={{ flex: 1, overflowY: 'auto', minHeight: 0, paddingBottom: bottomPadding }}
|
||||||
>
|
>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{/* 内置节假日 */}
|
{/* 内置节假日 */}
|
||||||
|
|||||||
@ -23,22 +23,30 @@ type ViewMode = 'edit' | 'preview';
|
|||||||
|
|
||||||
export function NoteEditor({ onSave }: NoteEditorProps) {
|
export function NoteEditor({ onSave }: NoteEditorProps) {
|
||||||
const notes = useAppStore((state) => state.notes);
|
const notes = useAppStore((state) => state.notes);
|
||||||
const updateNotesContent = useAppStore((state) => state.updateNotesContent);
|
|
||||||
const saveNotes = useAppStore((state) => state.saveNotes);
|
const saveNotes = useAppStore((state) => state.saveNotes);
|
||||||
const fetchNotes = useAppStore((state) => state.fetchNotes);
|
const fetchNotes = useAppStore((state) => state.fetchNotes);
|
||||||
|
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
const [viewMode] = useState<ViewMode>('edit');
|
const [viewMode] = useState<ViewMode>('edit');
|
||||||
|
|
||||||
// Initialize content from notes
|
// Initialize content from notes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (notes) {
|
if (notes) {
|
||||||
setContent(notes.content);
|
setContent(notes.content);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
}
|
}
|
||||||
}, [notes]);
|
}, [notes]);
|
||||||
|
|
||||||
|
// Track unsaved changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (notes && content !== notes.content) {
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
}
|
||||||
|
}, [content, notes]);
|
||||||
|
|
||||||
// Fetch notes on mount
|
// Fetch notes on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchNotes();
|
fetchNotes();
|
||||||
@ -65,21 +73,21 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (debouncedContent !== undefined && notes && debouncedContent !== content) {
|
|
||||||
handleSave(debouncedContent);
|
|
||||||
}
|
|
||||||
}, [debouncedContent]);
|
|
||||||
|
|
||||||
const handleSave = useCallback(
|
const handleSave = useCallback(
|
||||||
async (value: string) => {
|
async (value: string) => {
|
||||||
if (!notes) return;
|
// 即使 notes 为 null,saveNotes 也会自动创建便签
|
||||||
|
if (!value) return;
|
||||||
|
// 防止重复保存
|
||||||
|
if (saving) return;
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
updateNotesContent(value);
|
// 注意:不要在这里调用 updateNotesContent
|
||||||
|
// 因为 saveNotes 会在成功后更新 store 中的 notes
|
||||||
|
// 如果这里先更新,会触发 notes useEffect,导致 content 被重置
|
||||||
await saveNotes(value);
|
await saveNotes(value);
|
||||||
setLastSaved(new Date());
|
setLastSaved(new Date());
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
onSave?.();
|
onSave?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save note:', error);
|
console.error('Failed to save note:', error);
|
||||||
@ -87,20 +95,30 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[notes, updateNotesContent, saveNotes, onSave]
|
[saving, saveNotes, onSave]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Auto-save with debounce
|
||||||
|
useEffect(() => {
|
||||||
|
// 即使 notes 为 null 也可以自动保存(会创建新便签)
|
||||||
|
if (debouncedContent !== undefined && debouncedContent !== content) {
|
||||||
|
handleSave(debouncedContent);
|
||||||
|
}
|
||||||
|
}, [debouncedContent, content, handleSave]);
|
||||||
|
|
||||||
const formatLastSaved = () => {
|
const formatLastSaved = () => {
|
||||||
if (!lastSaved) return '未保存';
|
if (saving) return '保存中...';
|
||||||
|
if (hasUnsavedChanges && !lastSaved) return '未保存';
|
||||||
|
if (!lastSaved) return '';
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = now.getTime() - lastSaved.getTime();
|
const diff = now.getTime() - lastSaved.getTime();
|
||||||
if (diff < 1000) return '刚刚保存';
|
if (diff < 1000) return '已保存';
|
||||||
if (diff < 60000) return `${Math.floor(diff / 1000)}秒前保存`;
|
if (diff < 60000) return `${Math.floor(diff / 1000)}秒前保存`;
|
||||||
return lastSaved.toLocaleTimeString('zh-CN');
|
return lastSaved.toLocaleTimeString('zh-CN');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleManualSave = () => {
|
const handleManualSave = () => {
|
||||||
if (content && notes) {
|
if (content) {
|
||||||
handleSave(content);
|
handleSave(content);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -150,8 +168,8 @@ export function NoteEditor({ onSave }: NoteEditorProps) {
|
|||||||
>
|
>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
<Text size="xs" c={saving ? '#666' : lastSaved ? '#999' : '#bbb'}>
|
<Text size="xs" c={saving ? '#666' : hasUnsavedChanges ? '#e6a23c' : '#999'}>
|
||||||
{saving ? '保存中...' : formatLastSaved()}
|
{formatLastSaved()}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef, useState } from 'react';
|
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@ -43,6 +43,39 @@ export function ReminderList({
|
|||||||
}: ReminderListProps) {
|
}: ReminderListProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [bottomPadding, setBottomPadding] = useState(0);
|
||||||
|
|
||||||
|
// 检测列表内容,添加底部填充避免被 AI 输入框遮挡
|
||||||
|
useEffect(() => {
|
||||||
|
const updatePadding = () => {
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// 判断是否可滚动(内容高度 > 容器高度)
|
||||||
|
const isScrollable = container.scrollHeight > container.clientHeight;
|
||||||
|
// 计算内容底部到容器底部的距离
|
||||||
|
const scrollBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
// 如果可滚动,始终添加填充;如果不可滚动但内容接近底部,也添加填充
|
||||||
|
const needsPadding = isScrollable || scrollBottom < 100;
|
||||||
|
// 底部填充高度:AI 输入框高度(约50px) + 底部间距(48px) + 额外缓冲(20px)
|
||||||
|
setBottomPadding(needsPadding ? 120 : 20);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 延迟执行确保 DOM 完全渲染
|
||||||
|
const timer = setTimeout(updatePadding, 100);
|
||||||
|
|
||||||
|
// 监听容器和内容变化
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
if (container) {
|
||||||
|
const observer = new ResizeObserver(updatePadding);
|
||||||
|
observer.observe(container);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
// 分类折叠状态
|
// 分类折叠状态
|
||||||
const [collapsed, setCollapsed] = useState({
|
const [collapsed, setCollapsed] = useState({
|
||||||
@ -278,6 +311,7 @@ export function ReminderList({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
|
paddingBottom: bottomPadding,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { FixedCalendar } from '../components/common/FixedCalendar';
|
|||||||
import { TimePicker } from '../components/common/TimePicker';
|
import { TimePicker } from '../components/common/TimePicker';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAppStore } from '../stores';
|
import { useAppStore } from '../stores';
|
||||||
|
import { useContextMenuStore } from '../stores/contextMenu';
|
||||||
import { AnniversaryList } from '../components/anniversary/AnniversaryList';
|
import { AnniversaryList } from '../components/anniversary/AnniversaryList';
|
||||||
import { ReminderList } from '../components/reminder/ReminderList';
|
import { ReminderList } from '../components/reminder/ReminderList';
|
||||||
import { NoteEditor } from '../components/note/NoteEditor';
|
import { NoteEditor } from '../components/note/NoteEditor';
|
||||||
@ -34,6 +35,7 @@ export function HomePage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = useAppStore((state) => state.user);
|
const user = useAppStore((state) => state.user);
|
||||||
const logout = useAppStore((state) => state.logout);
|
const logout = useAppStore((state) => state.logout);
|
||||||
|
const openEventId = useContextMenuStore((state) => state.openEventId);
|
||||||
const checkAuth = useAppStore((state) => state.checkAuth);
|
const checkAuth = useAppStore((state) => state.checkAuth);
|
||||||
const events = useAppStore((state) => state.events);
|
const events = useAppStore((state) => state.events);
|
||||||
const fetchEvents = useAppStore((state) => state.fetchEvents);
|
const fetchEvents = useAppStore((state) => state.fetchEvents);
|
||||||
@ -180,8 +182,8 @@ export function HomePage() {
|
|||||||
});
|
});
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
console.error('更新失败:', result.error);
|
console.error('更新失败:', result.error);
|
||||||
|
// stores 会在更新失败时自动回滚数据
|
||||||
}
|
}
|
||||||
// 乐观更新已处理 UI 响应,无需 fetchEvents
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (event: Event) => {
|
const handleDelete = async (event: Event) => {
|
||||||
@ -223,6 +225,8 @@ export function HomePage() {
|
|||||||
if (result.error) {
|
if (result.error) {
|
||||||
console.error('顺延失败:', result.error);
|
console.error('顺延失败:', result.error);
|
||||||
}
|
}
|
||||||
|
// 刷新列表以更新 UI
|
||||||
|
fetchEvents();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDateChange = async (
|
const handleDateChange = async (
|
||||||
@ -391,8 +395,6 @@ export function HomePage() {
|
|||||||
onDateChange={handleDateChange}
|
onDateChange={handleDateChange}
|
||||||
onPriorityChange={handlePriorityChange}
|
onPriorityChange={handlePriorityChange}
|
||||||
/>
|
/>
|
||||||
{/* 底部空白区域 - 避免被 AI 输入框遮挡 */}
|
|
||||||
<Box style={{ height: 120 }} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right column - Note */}
|
{/* Right column - Note */}
|
||||||
@ -402,7 +404,7 @@ export function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Chat - Floating */}
|
{/* AI Chat - Floating */}
|
||||||
<FloatingAIChat onEventCreated={handleAIEventCreated} />
|
<FloatingAIChat onEventCreated={handleAIEventCreated} hidden={opened || !!openEventId} />
|
||||||
|
|
||||||
{/* Add/Edit Event Modal */}
|
{/* Add/Edit Event Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@ -39,7 +39,12 @@ export const api = {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error || 'Request failed');
|
const errorMsg = data.error || 'Request failed';
|
||||||
|
// 如果有详细信息,显示出来
|
||||||
|
if (data.details) {
|
||||||
|
console.error('Validation details:', JSON.stringify(data.details));
|
||||||
|
}
|
||||||
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user