Compare commits

...

2 Commits

Author SHA1 Message Date
ddshi
8725108195 fix: 修复多个交互问题
1. 修复便签保存后无法编辑的问题
   - 移除 handleSave 中对 updateNotesContent 的调用
   - 避免触发 notes useEffect 导致 content 被重置

2. 修复提醒顺延后列表不刷新
   - 在 handlePostpone 函数末尾添加 fetchEvents()

3. 优化提醒完成状态切换的错误处理
   - stores 会在更新失败时自动回滚数据

4. 优化便签保存状态显示
   - 添加 hasUnsavedChanges 状态
   - 区分"未保存"、"保存中"、"已保存"三种状态

5. 修复列表底部填充问题
   - 纪念日列表和提醒列表在内容刚好一屏时
   - 添加基础填充避免被 AI 输入框遮挡

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:58:08 +08:00
ddshi
864051a65b feat: 优化列表滚动和输入框交互体验
- 纪念日/提醒列表添加动态底部填充,避免被 AI 输入框遮挡
- AI 输入框在弹窗和右键菜单打开时自动隐藏
- 优化输入框样式,提升在浅色背景上的可见度

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 20:20:04 +08:00
6 changed files with 129 additions and 30 deletions

View File

@ -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',

View File

@ -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">
{/* 内置节假日 */} {/* 内置节假日 */}

View File

@ -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 为 nullsaveNotes 也会自动创建便签
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>

View File

@ -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">

View File

@ -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

View File

@ -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;