feat(笔记): 实现完整的笔记管理功能

- 新增笔记列表页面,支持搜索、筛选和排序
- 新增笔记卡片组件,展示笔记摘要和标签
- 新增笔记详情弹框,支持查看和编辑
- 新增保存到笔记弹框,从 AI 回复快速保存
- 侧边栏添加我的笔记入口
- AI 消息添加保存到笔记按钮
This commit is contained in:
gaoziman 2025-12-21 16:05:03 +08:00
parent bd83bc501d
commit 79b871d203
8 changed files with 1209 additions and 3 deletions

View File

@ -8,6 +8,7 @@ import { Sidebar, SidebarToggle } from '@/components/layout/Sidebar';
import { ChatInput } from '@/components/features/ChatInput'; import { ChatInput } from '@/components/features/ChatInput';
import { MessageBubble } from '@/components/features/MessageBubble'; import { MessageBubble } from '@/components/features/MessageBubble';
import { ChatHeaderInfo } from '@/components/features/ChatHeader'; import { ChatHeaderInfo } from '@/components/features/ChatHeader';
import { SaveToNoteModal } from '@/components/features/SaveToNoteModal';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useConversation, useConversations } from '@/hooks/useConversations'; import { useConversation, useConversations } from '@/hooks/useConversations';
import { useStreamChat, type ChatMessage } from '@/hooks/useStreamChat'; import { useStreamChat, type ChatMessage } from '@/hooks/useStreamChat';
@ -39,6 +40,10 @@ export default function ChatPage({ params }: PageProps) {
const titleInputRef = useRef<HTMLInputElement>(null); const titleInputRef = useRef<HTMLInputElement>(null);
const titleMenuRef = useRef<HTMLDivElement>(null); const titleMenuRef = useRef<HTMLDivElement>(null);
// 保存笔记状态
const [noteModalOpen, setNoteModalOpen] = useState(false);
const [noteContent, setNoteContent] = useState('');
// 获取数据 // 获取数据
const { conversation, loading: conversationLoading, error: conversationError } = useConversation(chatId); const { conversation, loading: conversationLoading, error: conversationError } = useConversation(chatId);
const { createConversation, updateConversation, deleteConversation } = useConversations(); const { createConversation, updateConversation, deleteConversation } = useConversations();
@ -303,6 +308,33 @@ export default function ChatPage({ params }: PageProps) {
} }
}; };
// 打开保存笔记弹框
const handleSaveToNote = (content: string) => {
setNoteContent(content);
setNoteModalOpen(true);
};
// 保存笔记
const handleSaveNote = async (params: { title: string; content: string; tags: string[] }): Promise<boolean> => {
try {
const response = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...params,
conversationId: chatId,
}),
});
if (!response.ok) {
throw new Error('保存失败');
}
return true;
} catch (error) {
console.error('Failed to save note:', error);
return false;
}
};
// 转换模型格式 // 转换模型格式
const modelOptions = models.map((m) => ({ const modelOptions = models.map((m) => ({
id: m.modelId, id: m.modelId,
@ -532,6 +564,8 @@ export default function ChatPage({ params }: PageProps) {
uploadedDocuments={message.uploadedDocuments} uploadedDocuments={message.uploadedDocuments}
pyodideStatus={message.pyodideStatus} pyodideStatus={message.pyodideStatus}
onRegenerate={message.role === 'assistant' && !isStreaming ? handleRegenerate : undefined} onRegenerate={message.role === 'assistant' && !isStreaming ? handleRegenerate : undefined}
onSaveToNote={message.role === 'assistant' && !isStreaming ? handleSaveToNote : undefined}
conversationId={chatId}
/> />
)) ))
)} )}
@ -571,6 +605,15 @@ export default function ChatPage({ params }: PageProps) {
</div> </div>
</div> </div>
</main> </main>
{/* 保存笔记弹框 */}
<SaveToNoteModal
isOpen={noteModalOpen}
onClose={() => setNoteModalOpen(false)}
onSave={handleSaveNote}
initialContent={noteContent}
conversationId={chatId}
/>
</div> </div>
); );
} }

222
src/app/notes/page.tsx Normal file
View File

@ -0,0 +1,222 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Search, Bookmark, Loader2, Filter, Archive, Tag } from 'lucide-react';
import { AppLayout } from '@/components/layout/AppLayout';
import { NoteCard } from '@/components/features/NoteCard';
import { NoteDetailModal } from '@/components/features/NoteDetailModal';
import { useNotes, type Note } from '@/hooks/useNotes';
import { cn } from '@/lib/utils';
export default function NotesPage() {
const {
notes,
loading,
fetchNotes,
updateNote,
deleteNote,
togglePin,
toggleArchive,
getAllTags,
} = useNotes();
const [searchQuery, setSearchQuery] = useState('');
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
// 加载笔记
const loadNotes = useCallback(async () => {
await fetchNotes({
search: searchQuery || undefined,
tags: selectedTag ? [selectedTag] : undefined,
isArchived: showArchived,
});
}, [fetchNotes, searchQuery, selectedTag, showArchived]);
useEffect(() => {
loadNotes();
}, [loadNotes]);
// 获取所有标签
const allTags = getAllTags();
// 打开详情弹窗
const handleOpenDetail = (note: Note) => {
setSelectedNote(note);
setShowDetailModal(true);
};
// 关闭详情弹窗
const handleCloseDetail = () => {
setShowDetailModal(false);
setSelectedNote(null);
};
// 处理置顶
const handleTogglePin = async (noteId: string): Promise<boolean> => {
const result = await togglePin(noteId);
// 更新选中的笔记
if (selectedNote?.noteId === noteId) {
setSelectedNote(prev => prev ? { ...prev, isPinned: !prev.isPinned } : null);
}
return result;
};
// 处理归档
const handleToggleArchive = async (noteId: string): Promise<boolean> => {
const result = await toggleArchive(noteId);
// 更新选中的笔记
if (selectedNote?.noteId === noteId) {
setSelectedNote(prev => prev ? { ...prev, isArchived: !prev.isArchived } : null);
}
return result;
};
// 处理删除
const handleDelete = async (noteId: string) => {
const success = await deleteNote(noteId);
return success;
};
return (
<AppLayout>
<div className="flex-1 flex flex-col h-full overflow-hidden w-full">
{/* 头部 */}
<header className="shrink-0 px-6 py-4 border-b border-[var(--color-border-light)]">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-[var(--color-primary-light)] rounded-xl flex items-center justify-center">
<Bookmark size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">
</h1>
<p className="text-sm text-[var(--color-text-tertiary)] mt-0.5">
</p>
</div>
</div>
<button
onClick={() => setShowArchived(!showArchived)}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg transition-colors',
showArchived
? 'bg-[var(--color-primary)] text-white'
: 'bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
)}
>
<Archive size={18} />
{showArchived ? '查看全部' : '已归档'}
</button>
</div>
{/* 搜索栏 */}
<div className="relative max-w-md">
<Search
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]"
/>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索笔记标题或内容..."
className="w-full pl-10 pr-4 py-2.5 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] focus:outline-none focus:border-[var(--color-primary)] transition-colors"
/>
</div>
</div>
</header>
{/* 标签筛选 */}
{allTags.length > 0 && (
<div className="shrink-0 px-6 py-3 border-b border-[var(--color-border-light)] overflow-x-auto">
<div className="max-w-7xl mx-auto flex gap-2">
<button
onClick={() => setSelectedTag(null)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full whitespace-nowrap transition-colors',
selectedTag === null
? 'bg-[var(--color-primary)] text-white'
: 'bg-[var(--color-bg-secondary)] border border-[var(--color-border)] text-[var(--color-text-primary)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]'
)}
>
<Filter size={14} />
</button>
{allTags.map((tag) => (
<button
key={tag}
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full whitespace-nowrap transition-colors',
selectedTag === tag
? 'bg-[var(--color-primary)] text-white'
: 'bg-[var(--color-bg-secondary)] border border-[var(--color-border)] text-[var(--color-text-primary)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)]'
)}
>
<Tag size={12} />
{tag}
</button>
))}
</div>
</div>
)}
{/* 笔记列表 */}
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-7xl mx-auto">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 size={32} className="animate-spin text-[var(--color-text-tertiary)]" />
</div>
) : notes.length === 0 ? (
<div className="text-center py-20">
<div className="w-20 h-20 mx-auto mb-4 bg-[var(--color-bg-secondary)] rounded-full flex items-center justify-center">
<Bookmark size={40} className="text-[var(--color-text-tertiary)]" />
</div>
<h3 className="text-lg font-medium text-[var(--color-text-primary)] mb-2">
{showArchived ? '暂无已归档的笔记' : '暂无笔记'}
</h3>
<p className="text-[var(--color-text-tertiary)] max-w-sm mx-auto">
{showArchived
? '归档的笔记会显示在这里'
: searchQuery
? '未找到匹配的笔记,试试其他关键词'
: '在对话中点击"保存到笔记"按钮,即可将精彩内容保存到这里'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{notes.map((note) => (
<NoteCard
key={note.noteId}
note={note}
onClick={() => handleOpenDetail(note)}
onTogglePin={handleTogglePin}
onToggleArchive={handleToggleArchive}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
</div>
</div>
{/* 详情弹窗 */}
<NoteDetailModal
isOpen={showDetailModal}
onClose={handleCloseDetail}
note={selectedNote}
onUpdate={updateNote}
onTogglePin={handleTogglePin}
onToggleArchive={handleToggleArchive}
onDelete={handleDelete}
/>
</AppLayout>
);
}

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Copy, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode } from 'lucide-react'; import { Copy, RefreshCw, ChevronDown, ChevronUp, Brain, Loader2, AlertCircle, Check, FileText, FileCode, Bookmark } from 'lucide-react';
import { Avatar } from '@/components/ui/Avatar'; import { Avatar } from '@/components/ui/Avatar';
import { AILogo } from '@/components/ui/AILogo'; import { AILogo } from '@/components/ui/AILogo';
import { Tooltip } from '@/components/ui/Tooltip'; import { Tooltip } from '@/components/ui/Tooltip';
@ -33,6 +33,10 @@ interface MessageBubbleProps {
}; };
/** 重新生成回调(仅对 AI 消息有效),传入消息 ID */ /** 重新生成回调(仅对 AI 消息有效),传入消息 ID */
onRegenerate?: (messageId: string) => void; onRegenerate?: (messageId: string) => void;
/** 保存到笔记回调(仅对 AI 消息有效),传入消息内容 */
onSaveToNote?: (content: string) => void;
/** 对话 ID用于关联笔记来源 */
conversationId?: string;
} }
// 格式化文件大小 // 格式化文件大小
@ -52,7 +56,7 @@ function getDocumentIcon(type: string) {
return FileText; return FileText;
} }
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, uploadedImages, uploadedDocuments, pyodideStatus, onRegenerate }: MessageBubbleProps) { export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, uploadedImages, uploadedDocuments, pyodideStatus, onRegenerate, onSaveToNote, conversationId }: MessageBubbleProps) {
const isUser = message.role === 'user'; const isUser = message.role === 'user';
const [thinkingExpanded, setThinkingExpanded] = useState(false); const [thinkingExpanded, setThinkingExpanded] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -328,6 +332,17 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
</button> </button>
</Tooltip> </Tooltip>
)} )}
{/* 保存到笔记按钮 */}
{onSaveToNote && (
<Tooltip content="保存到笔记">
<button
onClick={() => onSaveToNote(message.content)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors cursor-pointer"
>
<Bookmark size={16} />
</button>
</Tooltip>
)}
</div> </div>
{/* 免责声明 */} {/* 免责声明 */}

View File

@ -0,0 +1,186 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Pin, Archive, Trash2, MoreVertical, Tag, Calendar, MessageSquare } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Note } from '@/hooks/useNotes';
interface NoteCardProps {
note: Note;
onClick: () => void;
onTogglePin: (noteId: string) => void;
onToggleArchive: (noteId: string) => void;
onDelete: (noteId: string) => void;
}
// 格式化日期
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return '今天';
} else if (days === 1) {
return '昨天';
} else if (days < 7) {
return `${days}天前`;
} else {
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
}
// 截取内容预览
function getContentPreview(content: string, maxLength: number = 100): string {
// 移除 Markdown 标记
const plainText = content
.replace(/```[\s\S]*?```/g, '[代码块]')
.replace(/`[^`]+`/g, '[代码]')
.replace(/#+\s*/g, '')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '[图片]')
.replace(/\n+/g, ' ')
.trim();
if (plainText.length <= maxLength) return plainText;
return plainText.slice(0, maxLength) + '...';
}
export function NoteCard({ note, onClick, onTogglePin, onToggleArchive, onDelete }: NoteCardProps) {
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// 点击外部关闭菜单
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setMenuOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 处理删除确认
const handleDelete = () => {
if (confirm('确定要删除这条笔记吗?')) {
onDelete(note.noteId);
}
setMenuOpen(false);
};
return (
<div
className={cn(
'group relative bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-xl p-4 cursor-pointer transition-all duration-200',
'hover:border-[var(--color-primary)] hover:shadow-md',
note.isPinned && 'border-[var(--color-primary)]/30 bg-[var(--color-primary)]/5'
)}
onClick={onClick}
>
{/* 置顶标记 */}
{note.isPinned && (
<div className="absolute -top-2 -right-2 w-6 h-6 bg-[var(--color-primary)] text-white rounded-full flex items-center justify-center shadow-sm">
<Pin size={12} className="rotate-45" />
</div>
)}
{/* 标题 */}
<h3 className="text-base font-semibold text-[var(--color-text-primary)] mb-2 line-clamp-1 pr-8">
{note.title}
</h3>
{/* 内容预览 */}
<p className="text-sm text-[var(--color-text-secondary)] mb-3 line-clamp-2">
{getContentPreview(note.content)}
</p>
{/* 标签 */}
{note.tags && note.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{note.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-0.5 px-2 py-0.5 bg-[var(--color-bg-secondary)] text-[var(--color-text-tertiary)] text-xs rounded-md"
>
<Tag size={10} />
{tag}
</span>
))}
{note.tags.length > 3 && (
<span className="text-xs text-[var(--color-text-tertiary)]">
+{note.tags.length - 3}
</span>
)}
</div>
)}
{/* 底部信息 */}
<div className="flex items-center justify-between text-xs text-[var(--color-text-tertiary)]">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<Calendar size={12} />
{formatDate(note.createdAt)}
</span>
{note.conversationId && (
<span className="flex items-center gap-1">
<MessageSquare size={12} />
</span>
)}
</div>
{/* 更多操作 */}
<div
ref={menuRef}
className="relative"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => setMenuOpen(!menuOpen)}
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-[var(--color-bg-hover)] transition-all"
>
<MoreVertical size={16} />
</button>
{/* 下拉菜单 */}
{menuOpen && (
<div className="absolute right-0 bottom-full mb-1 w-36 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-lg shadow-lg py-1 z-10">
<button
onClick={() => {
onTogglePin(note.noteId);
setMenuOpen(false);
}}
className="w-full px-3 py-2 text-left text-sm text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] flex items-center gap-2"
>
<Pin size={14} className={note.isPinned ? 'text-[var(--color-primary)]' : ''} />
{note.isPinned ? '取消置顶' : '置顶'}
</button>
<button
onClick={() => {
onToggleArchive(note.noteId);
setMenuOpen(false);
}}
className="w-full px-3 py-2 text-left text-sm text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] flex items-center gap-2"
>
<Archive size={14} />
{note.isArchived ? '取消归档' : '归档'}
</button>
<div className="h-px bg-[var(--color-border)] my-1" />
<button
onClick={handleDelete}
className="w-full px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50 flex items-center gap-2"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,274 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { X, Pin, Archive, Trash2, Copy, Tag, Calendar, MessageSquare, Check, Edit3, Save } from 'lucide-react';
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
import { cn } from '@/lib/utils';
import type { Note, UpdateNoteParams } from '@/hooks/useNotes';
interface NoteDetailModalProps {
isOpen: boolean;
onClose: () => void;
note: Note | null;
onUpdate: (noteId: string, params: UpdateNoteParams) => Promise<Note | null>;
onTogglePin: (noteId: string) => Promise<boolean>;
onToggleArchive: (noteId: string) => Promise<boolean>;
onDelete: (noteId: string) => Promise<boolean>;
}
// 格式化日期
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function NoteDetailModal({
isOpen,
onClose,
note,
onUpdate,
onTogglePin,
onToggleArchive,
onDelete,
}: NoteDetailModalProps) {
const [isEditing, setIsEditing] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [editContent, setEditContent] = useState('');
const [copied, setCopied] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
// 初始化编辑状态
useEffect(() => {
if (note) {
setEditTitle(note.title);
setEditContent(note.content);
setIsEditing(false);
}
}, [note]);
// 点击外部关闭
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
if (!isEditing) onClose();
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
if (isEditing) {
setIsEditing(false);
setEditTitle(note?.title || '');
setEditContent(note?.content || '');
} else {
onClose();
}
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
};
}, [isOpen, isEditing, note, onClose]);
// 复制内容
const handleCopy = async () => {
if (!note) return;
try {
await navigator.clipboard.writeText(note.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};
// 保存编辑
const handleSave = async () => {
if (!note || !editTitle.trim() || !editContent.trim()) return;
await onUpdate(note.noteId, {
title: editTitle.trim(),
content: editContent.trim(),
});
setIsEditing(false);
};
// 删除笔记
const handleDelete = async () => {
if (!note) return;
if (confirm('确定要删除这条笔记吗?')) {
const success = await onDelete(note.noteId);
if (success) {
onClose();
}
}
};
if (!isOpen || !note) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div
ref={modalRef}
className={cn(
'w-full max-w-3xl max-h-[85vh] mx-4 bg-[var(--color-bg-primary)] rounded-2xl shadow-2xl flex flex-col',
'animate-in fade-in-0 zoom-in-95 duration-200'
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)]">
<div className="flex items-center gap-3 flex-1 min-w-0">
{note.isPinned && (
<Pin size={16} className="text-[var(--color-primary)] rotate-45 flex-shrink-0" />
)}
{isEditing ? (
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="flex-1 text-lg font-semibold text-[var(--color-text-primary)] bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg px-3 py-1.5 focus:outline-none focus:border-[var(--color-primary)]"
/>
) : (
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] truncate">
{note.title}
</h2>
)}
</div>
<div className="flex items-center gap-1 ml-4">
{isEditing ? (
<>
<button
onClick={() => {
setIsEditing(false);
setEditTitle(note.title);
setEditContent(note.content);
}}
className="p-2 rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
>
<X size={18} />
</button>
<button
onClick={handleSave}
className="p-2 rounded-lg text-[var(--color-primary)] hover:bg-[var(--color-primary-light)] transition-colors"
>
<Save size={18} />
</button>
</>
) : (
<>
<button
onClick={() => setIsEditing(true)}
className="p-2 rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
title="编辑"
>
<Edit3 size={18} />
</button>
<button
onClick={handleCopy}
className="p-2 rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
title="复制内容"
>
{copied ? <Check size={18} className="text-green-500" /> : <Copy size={18} />}
</button>
<button
onClick={() => onTogglePin(note.noteId)}
className={cn(
'p-2 rounded-lg transition-colors',
note.isPinned
? 'text-[var(--color-primary)] hover:bg-[var(--color-primary-light)]'
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)]'
)}
title={note.isPinned ? '取消置顶' : '置顶'}
>
<Pin size={18} />
</button>
<button
onClick={() => onToggleArchive(note.noteId)}
className="p-2 rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
title={note.isArchived ? '取消归档' : '归档'}
>
<Archive size={18} />
</button>
<button
onClick={handleDelete}
className="p-2 rounded-lg text-[var(--color-text-tertiary)] hover:bg-red-50 hover:text-red-500 transition-colors"
title="删除"
>
<Trash2 size={18} />
</button>
</>
)}
<div className="w-px h-5 bg-[var(--color-border)] mx-1" />
<button
onClick={onClose}
className="p-2 rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
>
<X size={18} />
</button>
</div>
</div>
{/* Meta info */}
<div className="px-6 py-3 border-b border-[var(--color-border)] bg-[var(--color-bg-secondary)]/50">
<div className="flex flex-wrap items-center gap-4 text-sm text-[var(--color-text-tertiary)]">
<span className="flex items-center gap-1">
<Calendar size={14} />
{formatDate(note.createdAt)}
</span>
{note.conversationId && (
<span className="flex items-center gap-1">
<MessageSquare size={14} />
</span>
)}
{note.tags && note.tags.length > 0 && (
<div className="flex items-center gap-2">
<Tag size={14} />
<div className="flex flex-wrap gap-1">
{note.tags.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 bg-[var(--color-primary-light)] text-[var(--color-primary)] text-xs rounded-md"
>
{tag}
</span>
))}
</div>
</div>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{isEditing ? (
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full h-full min-h-[300px] p-4 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded-lg text-[var(--color-text-primary)] font-mono text-sm resize-none focus:outline-none focus:border-[var(--color-primary)]"
placeholder="输入笔记内容..."
/>
) : (
<div className="prose prose-sm max-w-none">
<MarkdownRenderer content={note.content} />
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,252 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { X, Bookmark, Tag, Loader2, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
interface SaveToNoteModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (params: { title: string; content: string; tags: string[] }) => Promise<boolean>;
initialContent: string;
conversationId?: string;
messageId?: string;
}
export function SaveToNoteModal({
isOpen,
onClose,
onSave,
initialContent,
}: SaveToNoteModalProps) {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [tagInput, setTagInput] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
// 初始化内容
useEffect(() => {
if (isOpen) {
setContent(initialContent);
// 从内容中提取标题取第一行或前50个字符
const firstLine = initialContent.split('\n')[0].replace(/^#+\s*/, '').trim();
setTitle(firstLine.slice(0, 50) || '未命名笔记');
setTags([]);
setTagInput('');
setSaving(false);
setSaved(false);
// 聚焦到标题输入框
setTimeout(() => titleInputRef.current?.focus(), 100);
}
}, [isOpen, initialContent]);
// 点击外部关闭
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
if (!saving) onClose();
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape' && !saving) {
onClose();
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = '';
};
}, [isOpen, saving, onClose]);
// 添加标签
const addTag = () => {
const tag = tagInput.trim();
if (tag && !tags.includes(tag) && tags.length < 5) {
setTags([...tags, tag]);
setTagInput('');
}
};
// 处理标签输入按键
const handleTagKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
} else if (e.key === 'Backspace' && !tagInput && tags.length > 0) {
setTags(tags.slice(0, -1));
}
};
// 移除标签
const removeTag = (tagToRemove: string) => {
setTags(tags.filter(t => t !== tagToRemove));
};
// 保存笔记
const handleSave = async () => {
if (!title.trim() || !content.trim()) return;
setSaving(true);
const success = await onSave({
title: title.trim(),
content: content.trim(),
tags,
});
if (success) {
setSaved(true);
setTimeout(() => {
onClose();
}, 1000);
} else {
setSaving(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div
ref={modalRef}
className={cn(
'w-full max-w-2xl mx-4 bg-[var(--color-bg-primary)] rounded-md shadow-2xl',
'animate-in fade-in-0 zoom-in-95 duration-200'
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)]">
<div className="flex items-center gap-2">
<Bookmark className="w-5 h-5 text-[var(--color-primary)]" />
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
</h2>
</div>
<button
onClick={onClose}
disabled={saving}
className="p-1.5 rounded text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors disabled:opacity-50"
>
<X size={20} />
</button>
</div>
{/* Body */}
<div className="px-6 py-4 space-y-4">
{/* 标题 */}
<div>
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-1.5">
</label>
<input
ref={titleInputRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="输入笔记标题..."
className="w-full px-3 py-2.5 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded text-[var(--color-text-primary)] placeholder:text-[var(--color-text-placeholder)] focus:outline-none focus:border-[var(--color-primary)] transition-colors"
/>
</div>
{/* 标签 */}
<div>
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-1.5">
<span className="text-[var(--color-text-tertiary)]">5</span>
</label>
<div className="flex flex-wrap gap-2 p-3 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded focus-within:border-[var(--color-primary)] transition-colors">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-1 bg-[var(--color-primary-light)] text-[var(--color-primary)] text-sm rounded"
>
<Tag size={12} />
{tag}
<button
onClick={() => removeTag(tag)}
className="ml-0.5 hover:text-[var(--color-primary-hover)]"
>
<X size={12} />
</button>
</span>
))}
{tags.length < 5 && (
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown}
onBlur={addTag}
placeholder={tags.length === 0 ? "输入标签,按 Enter 添加..." : ""}
className="flex-1 min-w-[120px] bg-transparent border-none outline-none text-[var(--color-text-primary)] text-sm placeholder:text-[var(--color-text-placeholder)]"
/>
)}
</div>
</div>
{/* 内容预览 */}
<div>
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-1.5">
</label>
<div className="max-h-72 overflow-y-auto p-3 bg-[var(--color-bg-secondary)] border border-[var(--color-border)] rounded">
<pre className="text-sm text-[var(--color-text-primary)] whitespace-pre-wrap font-mono">
{content.length > 500 ? content.slice(0, 500) + '...' : content}
</pre>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--color-border)]">
<button
onClick={onClose}
disabled={saving}
className="px-4 py-2 text-sm font-medium text-[var(--color-text-primary)] bg-[var(--color-bg-secondary)] border border-[var(--color-border)] hover:bg-[var(--color-bg-hover)] hover:border-[var(--color-text-tertiary)] rounded transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleSave}
disabled={saving || !title.trim() || !content.trim()}
className={cn(
'px-4 py-2 text-sm font-medium rounded transition-all duration-200 flex items-center gap-2',
saved
? 'bg-green-500 text-white'
: 'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)] disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{saving ? (
<>
<Loader2 size={16} className="animate-spin" />
...
</>
) : saved ? (
<>
<Check size={16} />
</>
) : (
<>
<Bookmark size={16} />
</>
)}
</button>
</div>
</div>
</div>
);
}

View File

@ -2,7 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { Plus, PanelLeft, Trash2, MoreHorizontal, Loader2, Pencil, Check, X, Bot } from 'lucide-react'; import { Plus, PanelLeft, Trash2, MoreHorizontal, Loader2, Pencil, Check, X, Bot, Bookmark } from 'lucide-react';
import { UserMenu } from '@/components/ui/UserMenu'; import { UserMenu } from '@/components/ui/UserMenu';
import { NewChatModal } from '@/components/features/NewChatModal'; import { NewChatModal } from '@/components/features/NewChatModal';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -158,6 +158,22 @@ export function Sidebar({ isOpen = true }: SidebarProps) {
</Link> </Link>
</div> </div>
{/* 我的笔记入口 */}
<div className="px-4 pb-2">
<Link
href="/notes"
className={cn(
'flex items-center gap-2 text-sm font-medium py-2 transition-colors',
pathname === '/notes'
? 'text-[var(--color-primary)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-primary)]'
)}
>
<Bookmark size={18} />
<span></span>
</Link>
</div>
{/* 聊天列表 */} {/* 聊天列表 */}
<nav className="flex-1 overflow-y-auto px-2 flex flex-col gap-1"> <nav className="flex-1 overflow-y-auto px-2 flex flex-col gap-1">
{loading ? ( {loading ? (

198
src/hooks/useNotes.ts Normal file
View File

@ -0,0 +1,198 @@
'use client';
import { useState, useCallback } from 'react';
// 笔记数据类型
export interface Note {
id: number;
noteId: string;
userId: string;
conversationId?: string | null;
messageId?: string | null;
title: string;
content: string;
tags: string[];
isPinned: boolean;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
// 创建笔记参数
export interface CreateNoteParams {
title: string;
content: string;
tags?: string[];
conversationId?: string;
messageId?: string;
}
// 更新笔记参数
export interface UpdateNoteParams {
title?: string;
content?: string;
tags?: string[];
isPinned?: boolean;
isArchived?: boolean;
}
// 筛选参数
export interface NotesFilter {
search?: string;
tags?: string[];
isPinned?: boolean;
isArchived?: boolean;
}
/**
* Hook
*
*/
export function useNotes() {
const [notes, setNotes] = useState<Note[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 获取笔记列表
const fetchNotes = useCallback(async (filter?: NotesFilter) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (filter?.search) params.set('search', filter.search);
if (filter?.tags?.length) params.set('tags', filter.tags.join(','));
if (filter?.isPinned !== undefined) params.set('isPinned', String(filter.isPinned));
if (filter?.isArchived !== undefined) params.set('isArchived', String(filter.isArchived));
const response = await fetch(`/api/notes?${params.toString()}`);
if (!response.ok) {
throw new Error('获取笔记列表失败');
}
const data = await response.json();
setNotes(data.notes || []);
return data.notes || [];
} catch (err) {
const message = err instanceof Error ? err.message : '获取笔记列表失败';
setError(message);
return [];
} finally {
setLoading(false);
}
}, []);
// 创建笔记
const createNote = useCallback(async (params: CreateNoteParams): Promise<Note | null> => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || '创建笔记失败');
}
const data = await response.json();
// 将新笔记添加到列表开头
setNotes(prev => [data.note, ...prev]);
return data.note;
} catch (err) {
const message = err instanceof Error ? err.message : '创建笔记失败';
setError(message);
return null;
} finally {
setLoading(false);
}
}, []);
// 更新笔记
const updateNote = useCallback(async (noteId: string, params: UpdateNoteParams): Promise<Note | null> => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/notes/${noteId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || '更新笔记失败');
}
const data = await response.json();
// 更新列表中的笔记
setNotes(prev => prev.map(note => note.noteId === noteId ? data.note : note));
return data.note;
} catch (err) {
const message = err instanceof Error ? err.message : '更新笔记失败';
setError(message);
return null;
} finally {
setLoading(false);
}
}, []);
// 删除笔记
const deleteNote = useCallback(async (noteId: string): Promise<boolean> => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/notes/${noteId}`, {
method: 'DELETE',
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || '删除笔记失败');
}
// 从列表中移除笔记
setNotes(prev => prev.filter(note => note.noteId !== noteId));
return true;
} catch (err) {
const message = err instanceof Error ? err.message : '删除笔记失败';
setError(message);
return false;
} finally {
setLoading(false);
}
}, []);
// 切换置顶状态
const togglePin = useCallback(async (noteId: string): Promise<boolean> => {
const note = notes.find(n => n.noteId === noteId);
if (!note) return false;
const result = await updateNote(noteId, { isPinned: !note.isPinned });
return result !== null;
}, [notes, updateNote]);
// 切换归档状态
const toggleArchive = useCallback(async (noteId: string): Promise<boolean> => {
const note = notes.find(n => n.noteId === noteId);
if (!note) return false;
const result = await updateNote(noteId, { isArchived: !note.isArchived });
return result !== null;
}, [notes, updateNote]);
// 获取所有标签
const getAllTags = useCallback((): string[] => {
const tagSet = new Set<string>();
notes.forEach(note => {
note.tags?.forEach(tag => tagSet.add(tag));
});
return Array.from(tagSet);
}, [notes]);
return {
notes,
loading,
error,
fetchNotes,
createNote,
updateNote,
deleteNote,
togglePin,
toggleArchive,
getAllTags,
};
}