Compare commits
4 Commits
d6dc77f63a
...
79b871d203
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79b871d203 | ||
|
|
bd83bc501d | ||
|
|
6d45e3575d | ||
|
|
92ab731c62 |
160
src/app/api/notes/[noteId]/route.ts
Normal file
160
src/app/api/notes/[noteId]/route.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/drizzle/db';
|
||||||
|
import { notes } from '@/drizzle/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
|
||||||
|
// GET /api/notes/[noteId] - 获取单个笔记
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ noteId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { noteId } = await params;
|
||||||
|
|
||||||
|
// 获取当前用户
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '未登录' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = await db.query.notes.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(notes.noteId, noteId),
|
||||||
|
eq(notes.userId, user.userId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!note) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '笔记不存在' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ note });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get note:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取笔记失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/notes/[noteId] - 更新笔记
|
||||||
|
export async function PUT(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ noteId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { noteId } = await params;
|
||||||
|
|
||||||
|
// 获取当前用户
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '未登录' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证笔记存在且属于当前用户
|
||||||
|
const existingNote = await db.query.notes.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(notes.noteId, noteId),
|
||||||
|
eq(notes.userId, user.userId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingNote) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '笔记不存在' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { title, content, tags, isPinned, isArchived } = body;
|
||||||
|
|
||||||
|
// 构建更新数据
|
||||||
|
const updateData: Record<string, unknown> = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (title !== undefined) updateData.title = title.trim();
|
||||||
|
if (content !== undefined) updateData.content = content.trim();
|
||||||
|
if (tags !== undefined) updateData.tags = tags;
|
||||||
|
if (isPinned !== undefined) updateData.isPinned = isPinned;
|
||||||
|
if (isArchived !== undefined) updateData.isArchived = isArchived;
|
||||||
|
|
||||||
|
const [updatedNote] = await db
|
||||||
|
.update(notes)
|
||||||
|
.set(updateData)
|
||||||
|
.where(and(
|
||||||
|
eq(notes.noteId, noteId),
|
||||||
|
eq(notes.userId, user.userId)
|
||||||
|
))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({ note: updatedNote });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update note:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '更新笔记失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/notes/[noteId] - 删除笔记
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ noteId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { noteId } = await params;
|
||||||
|
|
||||||
|
// 获取当前用户
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '未登录' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证笔记存在且属于当前用户
|
||||||
|
const existingNote = await db.query.notes.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(notes.noteId, noteId),
|
||||||
|
eq(notes.userId, user.userId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingNote) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '笔记不存在' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(notes)
|
||||||
|
.where(and(
|
||||||
|
eq(notes.noteId, noteId),
|
||||||
|
eq(notes.userId, user.userId)
|
||||||
|
));
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete note:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '删除笔记失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/app/api/notes/route.ts
Normal file
122
src/app/api/notes/route.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/drizzle/db';
|
||||||
|
import { notes } from '@/drizzle/schema';
|
||||||
|
import { desc, eq, and, or, ilike, sql } from 'drizzle-orm';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { getCurrentUser } from '@/lib/auth';
|
||||||
|
|
||||||
|
// GET /api/notes - 获取当前用户的笔记列表
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
// 获取当前用户
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '未登录' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析查询参数
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const search = searchParams.get('search');
|
||||||
|
const tags = searchParams.get('tags');
|
||||||
|
const isPinned = searchParams.get('isPinned');
|
||||||
|
const isArchived = searchParams.get('isArchived');
|
||||||
|
|
||||||
|
// 构建查询条件
|
||||||
|
const conditions = [eq(notes.userId, user.userId)];
|
||||||
|
|
||||||
|
// 搜索条件(标题或内容)
|
||||||
|
if (search) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
ilike(notes.title, `%${search}%`),
|
||||||
|
ilike(notes.content, `%${search}%`)
|
||||||
|
)!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签筛选
|
||||||
|
if (tags) {
|
||||||
|
const tagList = tags.split(',').map(t => t.trim());
|
||||||
|
// 使用 jsonb 包含查询
|
||||||
|
conditions.push(
|
||||||
|
sql`${notes.tags} ?| array[${sql.join(tagList.map(t => sql`${t}`), sql`, `)}]`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 置顶筛选
|
||||||
|
if (isPinned !== null && isPinned !== undefined) {
|
||||||
|
conditions.push(eq(notes.isPinned, isPinned === 'true'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 归档筛选(默认不显示已归档)
|
||||||
|
if (isArchived !== null && isArchived !== undefined) {
|
||||||
|
conditions.push(eq(notes.isArchived, isArchived === 'true'));
|
||||||
|
} else {
|
||||||
|
conditions.push(eq(notes.isArchived, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteList = await db.query.notes.findMany({
|
||||||
|
where: and(...conditions),
|
||||||
|
orderBy: [desc(notes.isPinned), desc(notes.createdAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ notes: noteList });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get notes:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取笔记列表失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/notes - 创建新笔记
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
// 获取当前用户
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '未登录' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { title, content, tags, conversationId, messageId } = body;
|
||||||
|
|
||||||
|
// 验证必填字段
|
||||||
|
if (!title || !content) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '标题和内容不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteId = nanoid();
|
||||||
|
|
||||||
|
const [newNote] = await db
|
||||||
|
.insert(notes)
|
||||||
|
.values({
|
||||||
|
noteId,
|
||||||
|
userId: user.userId,
|
||||||
|
title: title.trim(),
|
||||||
|
content: content.trim(),
|
||||||
|
tags: tags || [],
|
||||||
|
conversationId: conversationId || null,
|
||||||
|
messageId: messageId || null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({ note: newNote }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create note:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '创建笔记失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
222
src/app/notes/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -50,6 +50,7 @@ export default function SettingsPage() {
|
|||||||
const { tools, loading: toolsLoading } = useTools();
|
const { tools, loading: toolsLoading } = useTools();
|
||||||
|
|
||||||
// CCH 配置状态
|
// CCH 配置状态
|
||||||
|
const [cchUrl, setCchUrl] = useState('');
|
||||||
const [cchApiKey, setCchApiKey] = useState('');
|
const [cchApiKey, setCchApiKey] = useState('');
|
||||||
const [showApiKey, setShowApiKey] = useState(false);
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
@ -68,6 +69,7 @@ export default function SettingsPage() {
|
|||||||
// 当设置加载完成后,更新本地状态
|
// 当设置加载完成后,更新本地状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
|
setCchUrl(settings.cchUrl || '');
|
||||||
setSystemPrompt(settings.systemPrompt || '');
|
setSystemPrompt(settings.systemPrompt || '');
|
||||||
setTemperature(settings.temperature || '0.7');
|
setTemperature(settings.temperature || '0.7');
|
||||||
}
|
}
|
||||||
@ -77,8 +79,12 @@ export default function SettingsPage() {
|
|||||||
const handleSaveCchConfig = async () => {
|
const handleSaveCchConfig = async () => {
|
||||||
setSaveStatus('saving');
|
setSaveStatus('saving');
|
||||||
try {
|
try {
|
||||||
if (cchApiKey) {
|
const updates: Record<string, string> = {};
|
||||||
await updateSettings({ cchApiKey });
|
if (cchUrl) updates.cchUrl = cchUrl;
|
||||||
|
if (cchApiKey) updates.cchApiKey = cchApiKey;
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await updateSettings(updates);
|
||||||
}
|
}
|
||||||
setSaveStatus('saved');
|
setSaveStatus('saved');
|
||||||
setCchApiKey(''); // 清除输入的 API Key
|
setCchApiKey(''); // 清除输入的 API Key
|
||||||
@ -313,6 +319,20 @@ export default function SettingsPage() {
|
|||||||
title="CCH 服务配置"
|
title="CCH 服务配置"
|
||||||
description="配置 Claude Code Hub 服务连接"
|
description="配置 Claude Code Hub 服务连接"
|
||||||
>
|
>
|
||||||
|
{/* 服务地址配置 */}
|
||||||
|
<SettingsItem
|
||||||
|
label="服务地址"
|
||||||
|
description="配置 CCH 服务的访问地址"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="settings-input w-80"
|
||||||
|
value={cchUrl}
|
||||||
|
onChange={(e) => setCchUrl(e.target.value)}
|
||||||
|
placeholder="https://claude.leocoder.cn/"
|
||||||
|
/>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
label="API Key"
|
label="API Key"
|
||||||
description={
|
description={
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
{/* 免责声明 */}
|
{/* 免责声明 */}
|
||||||
|
|||||||
186
src/components/features/NoteCard.tsx
Normal file
186
src/components/features/NoteCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
src/components/features/NoteDetailModal.tsx
Normal file
274
src/components/features/NoteDetailModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
252
src/components/features/SaveToNoteModal.tsx
Normal file
252
src/components/features/SaveToNoteModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 ? (
|
||||||
|
|||||||
15
src/drizzle/migrations/0007_fantastic_molten_man.sql
Normal file
15
src/drizzle/migrations/0007_fantastic_molten_man.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE "notes" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"note_id" varchar(64) NOT NULL,
|
||||||
|
"user_id" varchar(64) NOT NULL,
|
||||||
|
"conversation_id" varchar(64),
|
||||||
|
"message_id" varchar(64),
|
||||||
|
"title" varchar(255) NOT NULL,
|
||||||
|
"content" text NOT NULL,
|
||||||
|
"tags" jsonb DEFAULT '[]'::jsonb,
|
||||||
|
"is_pinned" boolean DEFAULT false,
|
||||||
|
"is_archived" boolean DEFAULT false,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now(),
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now(),
|
||||||
|
CONSTRAINT "notes_note_id_unique" UNIQUE("note_id")
|
||||||
|
);
|
||||||
1121
src/drizzle/migrations/meta/0007_snapshot.json
Normal file
1121
src/drizzle/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -50,6 +50,13 @@
|
|||||||
"when": 1766206123948,
|
"when": 1766206123948,
|
||||||
"tag": "0006_safe_spitfire",
|
"tag": "0006_safe_spitfire",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1766299055211,
|
||||||
|
"tag": "0007_fantastic_molten_man",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -262,6 +262,31 @@ export const models = pgTable('models', {
|
|||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 笔记表
|
||||||
|
// ============================================
|
||||||
|
export const notes = pgTable('notes', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
// 笔记唯一标识
|
||||||
|
noteId: varchar('note_id', { length: 64 }).notNull().unique(),
|
||||||
|
// 关联用户
|
||||||
|
userId: varchar('user_id', { length: 64 }).notNull(),
|
||||||
|
// 来源信息
|
||||||
|
conversationId: varchar('conversation_id', { length: 64 }), // 来源对话
|
||||||
|
messageId: varchar('message_id', { length: 64 }), // 来源消息
|
||||||
|
// 笔记内容
|
||||||
|
title: varchar('title', { length: 255 }).notNull(), // 标题
|
||||||
|
content: text('content').notNull(), // 内容(Markdown)
|
||||||
|
// 分类和标签
|
||||||
|
tags: jsonb('tags').$type<string[]>().default([]), // 标签数组
|
||||||
|
// 状态
|
||||||
|
isPinned: boolean('is_pinned').default(false), // 置顶
|
||||||
|
isArchived: boolean('is_archived').default(false), // 归档
|
||||||
|
// 时间戳
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 关系定义
|
// 关系定义
|
||||||
// ============================================
|
// ============================================
|
||||||
@ -332,6 +357,22 @@ export const messagesRelations = relations(messages, ({ one }) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 笔记关系
|
||||||
|
export const notesRelations = relations(notes, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [notes.userId],
|
||||||
|
references: [users.userId],
|
||||||
|
}),
|
||||||
|
conversation: one(conversations, {
|
||||||
|
fields: [notes.conversationId],
|
||||||
|
references: [conversations.conversationId],
|
||||||
|
}),
|
||||||
|
message: one(messages, {
|
||||||
|
fields: [notes.messageId],
|
||||||
|
references: [messages.messageId],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 类型定义
|
// 类型定义
|
||||||
// ============================================
|
// ============================================
|
||||||
@ -385,3 +426,6 @@ export type NewAssistant = typeof assistants.$inferInsert;
|
|||||||
|
|
||||||
export type UserFavoriteAssistant = typeof userFavoriteAssistants.$inferSelect;
|
export type UserFavoriteAssistant = typeof userFavoriteAssistants.$inferSelect;
|
||||||
export type NewUserFavoriteAssistant = typeof userFavoriteAssistants.$inferInsert;
|
export type NewUserFavoriteAssistant = typeof userFavoriteAssistants.$inferInsert;
|
||||||
|
|
||||||
|
export type Note = typeof notes.$inferSelect;
|
||||||
|
export type NewNote = typeof notes.$inferInsert;
|
||||||
|
|||||||
198
src/hooks/useNotes.ts
Normal file
198
src/hooks/useNotes.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user